﻿
using EmperialApps.WeatherSpark.Data;
using EmperialApps.WeatherSpark.Internal;
using EmperialApps.WeatherSpark.Resources;
using Microsoft.Phone.Controls;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Device.Location;
using System.Linq;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Threading;

namespace EmperialApps.WeatherSpark {

    public partial class ChooseLocationPage : ForecastPage {

        private const string ShowDebugLog = "Show debug log";
        private static readonly string[] DebugIdentifiers = new[] {
            ShowDebugLog,
            "Disable tile rescheduling",
            "Disable forecast auto-refresh"
        };

        private readonly DispatcherTimer _placesDownloadDelay;
        private readonly LocationStore _locationStore;
        private readonly Brush _searchBorderBrush;


        public ChooseLocationPage( ) {
            typeof( ChooseLocationPage ).Log( "Initializing" );
            this.DisableForecastSave = true;
            InitializeComponent( );

            this._placesDownloadDelay = new DispatcherTimer { Interval = TimeSpan.FromSeconds( 1 ) };
            this._placesDownloadDelay.Tick += this.OnPlacesDownloadDelayTick;

            this._locationStore = new LocationStore( this );
            this._locations = new LocationManager( this._locationStore );
            this._locations.Progress += this.OnSearchProgress;
            this._locationStore.DownloadCompleted += this.OnLocationDownloadCompleted;

            this._searchBorderBrush = this.Search.BorderBrush;
            this.UpdateDebugSettingsDisplay( );
        }


        private void UpdateDebugSettingsDisplay( ) {
            const byte DebugSettingEnabled = 0xAD;
            const byte DebugSettingDisabled = 0x00;
            byte r = Settings.DisableTileRescheduling ? DebugSettingEnabled : DebugSettingDisabled;
            byte b = Settings.DisableForecastAutoRefresh ? DebugSettingEnabled : DebugSettingDisabled;

            this.Search.BorderBrush =
                r + b > 0
                    ? new SolidColorBrush( Color.FromArgb( 0xBE, r, 0x00, b ) )
                    : _searchBorderBrush;
        }


        private void OnSearchGotFocus( object sender, RoutedEventArgs e ) {
            this.SearchHint.Visibility = Visibility.Collapsed;

            if( this.Search.ItemsSource == null )
                this.UpdateAvailablePlaces( );

            if( this.Search.ItemsSource != null )
                this.OpenSearchDropDown( );


            var textbox = e.OriginalSource as TextBox;
            if( textbox != null )
                textbox.SelectAll( );

            this.TitlePanel.Visibility = Visibility.Collapsed;
        }

        private void OnSearchLostFocus( object sender, RoutedEventArgs e ) {
            if( this.Search.Text.Length == 0 && this.Search.SelectedItem == null )
                this.SearchHint.Visibility = Visibility.Visible;

            this.TitlePanel.Visibility = Visibility.Visible;
            this.SelectFirstAvailablePlace( single: true );
        }

        private void OnSearchKeyUp( object sender, KeyEventArgs e ) {
            if( e.Key == Key.Enter )
                this.SelectFirstAvailablePlace( single: false );
        }

        private void OnSearchTextChanged( object sender, RoutedEventArgs e ) {
            // If we are using the geo service to discover the current location, disable manual search info.
            if( this.DiscoveringLocation ) {
                this.Search.ItemsSource = null;
                this.SearchHint.Visibility = Visibility.Collapsed;
                return;
            }

            // If the input text matches, toggle the appropriate debug setting.
            int debugSettingIndex = Array.IndexOf( DebugIdentifiers, this.Search.Text );
            if( debugSettingIndex >= 0 ) {
                var statusMessage = Localized.Status.DebugSettingUpdated;
                switch( debugSettingIndex ) {
                    default:
                    case 0:
                        this.DebugLog.Height = this.ActualHeight - 1.4 * this.TitlePanel.ActualHeight;
                        this.ContentPanel.Visibility = Visibility.Collapsed;
                        this.DebugLog.ItemsSource = Logger.GetLog( );
                        this.DebugLog.Focus( );
                        statusMessage = Localized.Status.DebugLogUpdated;
                        break;
                    case 1:
                        Settings.DisableTileRescheduling = !Settings.DisableTileRescheduling;
                        break;
                    case 2:
                        Settings.DisableForecastAutoRefresh = !Settings.DisableForecastAutoRefresh;
                        break;
                }

                this.UpdateDebugSettingsDisplay( );
                this.Search.Text = "";
                this.UpdateSearchProgress( SearchInfo.DebugPlaceholder, statusMessage, ProgressKind.Complete );
            }
            // Otherwise, if page has loaded and no item is selected, update available places.
            else if( this.HasLoaded ) {
                Place? place = this.Search.SelectedItem as Place?;
                if( !place.HasValue || place.Value.CityName != this.Search.Text )
                    this.UpdateAvailablePlaces( );
            }
        }

        private void OnSearchSelectionChanged( object sender, SelectionChangedEventArgs e ) {
            Place? place = this.Search.SelectedItem as Place?;
            if( place == this.CurrentSearch.Place )
                typeof( ChooseLocationPage ).Log( "Ignoring selection change to current search place." );
            else if( place.HasValue )
                this.SearchSelectionChanged( place.Value );
        }

        private void SearchSelectionChanged( Place place, SearchInfo targetSearch = null ) {
            // Ignore implicit selection of existing subscription.
            if( this.IsExistingSubscription( place, find: false ) )
                return;

            string searchText = this.Search.Text.Trim( );
            SearchInfoGroup group = this.SelectSearchesFromPlace( place, searchText );
            SearchInfo succeeded = group.Succeeded;
            if( succeeded != null && succeeded == (targetSearch ?? succeeded) )
                this.OnForecastDownloadComplete( succeeded, succeeded.Location );
            else
                this.TrySubscribe( group );

            this.EnsureSearchButtons( group );
            Button searchButton = group.First( ).Button;
            this.Dispatcher.BeginInvoke( new Func<bool>( searchButton.Focus ) );
        }

        protected override void OnNavigatedTo( NavigationEventArgs e ) {
            base.OnNavigatedTo( e );

            if( this._searches.Count == 0 ) {
                if( this.EnsureCurrentSettings( fallback: false ) || Settings.Current != null ) {
                    typeof( ChooseLocationPage ).Log( "- Applying existing forecast " + Settings.Current.GetIndex( ) );
                    this.Search.Text = Settings.Current.Search ?? "";
                    this.SearchHint.Visibility = Visibility.Collapsed;
                }

                // Add initial search to complete loading.
                SearchInfoGroup group = SearchInfo.Create( Settings.Current );
                this._searches.Add( group );
            }
        }

        protected override void OnNavigatedFrom( NavigationEventArgs e ) {
            typeof( ChooseLocationPage ).Log( "Leaving" );
            base.OnNavigatedFrom( e );
        }

        private void SaveSearchForecast( SearchInfo search ) {
            if( search.Forecast != null ) {
                var locationSettings = Settings.Current;
                Coordinate location = search.Forecast.Location;

                // Save downloaded forecast to file.
                int index = locationSettings != null ? locationSettings.GetIndex( ) : Settings.Count;
                search.Forecast.SaveToFile( typeof( ChooseLocationPage ), index );

                // Move or add settings for forecast.
                typeof( ChooseLocationPage ).Log( "Saving location " + index );
                if( locationSettings != null ) {
                    locationSettings.Search = search.Text;
                    locationSettings.DownloadUri = search.DownloadUri;

                    Settings.MoveLocation( locationSettings, location );
                }
                else {
                    locationSettings = new LocationSettings( location, search.Text, search.SourceId, search.DownloadUri );

                    Settings.AddLocation( locationSettings );
                    Settings.Current = locationSettings;
                }
            }
            else
                typeof( ChooseLocationPage ).Log( "- ignoring save with no forecast available" );

            NavigationService.GoBack( );
        }


        #region Places

        private readonly LocationManager _locations;


        private void OpenSearchDropDown( ) {
            try { this.Search.IsDropDownOpen = true; }
            catch( ArgumentException ex ) {
                this.Search.IsDropDownOpen = false;
                System.Diagnostics.Debug.WriteLine( "Could not open search drop down: {0}", ex );
            }
        }

        private void OnLocationDownloadCompleted( object sender, LocationEventArgs e ) {
            this.UpdateAvailablePlaces( );
        }

        private void UpdateAvailablePlaces( ) {
            this._placesDownloadDelay.Stop( );

            string text = this.Search.Text.Trim( );
            Place[] places = this.GetAvailablePlaces( text );
            if( places != null && places.Length > 0 ) {
                this.Search.ItemsSource = places;
                if( this.Search.SelectedItem == null ) {
                    bool isMatch;
                    Place place = places[0];

                    // If first available place is an exact match for the user-entered text, select it.
                    string compareText = text.Replace( " ", "" );
                    string longName = place.Name.Replace( " ", "" );
                    if( this.IsExistingSubscription( place, find: true ) )
                        isMatch = false;
                    else if( longName.Equals( compareText, StringComparison.CurrentCultureIgnoreCase ) )
                        isMatch = true;
                    else {
                        int firstSeparator = longName.IndexOf( ',' );
                        int lastSeparator = longName.LastIndexOf( ',' );
                        isMatch = firstSeparator != lastSeparator
                               && longName.Substring( 0, lastSeparator ).Equals( compareText, StringComparison.CurrentCultureIgnoreCase );
                    }

                    if( isMatch )
                        this.Search.SelectedItem = place;
                    else
                        this.OpenSearchDropDown( );
                }
            }
        }

        private void SelectFirstAvailablePlace( bool single ) {
            if( this.Search.SelectedItem != null )
                return;

            var places = (Place[])this.Search.ItemsSource;
            if( places != null && places.Length > 0
                   && (!single || places.Length == 1
                               || (places.Length == 2 && places[0].Name == places[1].Name)) )
                this.Search.SelectedItem = places[0];
        }

        private Place[] GetAvailablePlaces( string text ) {
            Place[] places;

            // If we have not matched this exact text, schedule a download.
            if( !this._locations.TryGetLocations( text, out places ) )
                this._placesDownloadDelay.Start( );
            // Otherwise, update progress with successful load.
            else if( this.Search.SelectedItem == null && !this.IsProgressComplete( ) )
                this.SetProgress( ProgressKind.Complete, 0 );

            return places;
        }

        private void OnPlacesDownloadDelayTick( object sender, EventArgs e ) {
            this._placesDownloadDelay.Stop( );

            string text = this.Search.Text.Trim( );
            if( text.Length > 1 ) {
                this._locations.DownloadLocations( text );

                if( this.Search.SelectedItem == null )
                    this.SetProgress( ProgressKind.Incomplete, Localized.Status.Searching );
            }
        }

        private void OnSearchProgress( object sender, ProgressChangedEventArgs e ) {
            if( this.Search.SelectedItem != null )
                return;

            string search, error;
            e.UserState.TryGetValues( out search, out error );
            if( error != null ) {
                this.SetProgress( ProgressKind.CompletedUnsuccessfully, Localized.Status.SearchError, new object[] { search, error } );
                return;
            }

            string numberSpecificId = "Status_" + Localized.Status.SearchFound + e.ProgressPercentage;
            string numberSpecificFormat = AppResources.ResourceManager.GetString( numberSpecificId );
            var status =
                numberSpecificFormat == null
                    ? Localized.Status.SearchFound
                    : Localized.Status.SearchFound0 + e.ProgressPercentage;

            Localized.FormatArgument args = new object[] { search, e.ProgressPercentage };
            this.SetProgress( e.ProgressPercentage == 0 ? ProgressKind.CompletedUnsuccessfully : ProgressKind.Complete, status, args.AddLogArgument( e.ProgressPercentage ) );
        }


        private sealed class LocationStore : ILocationStore {
            private const char Airport = '\u2708'; // ✈
            private static readonly Dictionary<int, string> AirportCodes = new Dictionary<int, string> { { 3, "IATA" }, { 4, "ICAO" } };

            private readonly Dictionary<string, Place[]> _locations = new Dictionary<string, Place[]>( StringComparer.OrdinalIgnoreCase );
            private readonly ChooseLocationPage _page;

            public LocationStore( ChooseLocationPage page ) {
                this._page = page;

                // Ensure corrections are up to date.
                ForecastSource.GetCorrections( );
            }

            public bool Save( LocationEventArgs e ) {
                Place[] places = e.Places;

                Place[] airports;
                if( this._locations.TryGetValue( Airport + e.Search, out airports ) ) {
                    typeof( ChooseLocationPage ).Log( "Combining airport and place searches" );
                    places = places == null || places.Length == 0
                           ? airports
                           : airports.Concat( places ).ToArray( );
                }

                this._locations[e.Search] = places;
                return places != null;
            }

            public Place[] Load( string key, object context ) {
                Place[] places;
                this._locations.TryGetValue( key, out places );
                return places;
            }

            public event EventHandler<LocationEventArgs> DownloadCompleted = delegate { };

            public void BeginDownload( string key ) {
                const string BaseSearch = @"http://api.geonames.org/search?username=WeatherSpark&isNameRequired=true&style=FULL&maxRows=45";
                string query = key.TrimStart( Airport );

                string search, userToken, airportCode;
                if( key[0] != Airport && AirportCodes.TryGetValue( key.Length, out airportCode ) ) {
                    typeof( ChooseLocationPage ).Log( "Beginning " + airportCode + " airport search" );
                    search = BaseSearch + @"&featureClass=S&featureCode=AIRP&searchlang=" + airportCode.ToLowerInvariant( ) + "&name=";
                    userToken = Airport + query;
                }
                else {
                    typeof( ChooseLocationPage ).Log( "Beginning place search" );
                    search = BaseSearch + @"&featureClass=P&q=";
                    userToken = query;
                }

                string lang = "&lang=" + System.Globalization.CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
                Uri address = new Uri( search + HttpUtility.UrlEncode( query ) + lang );

                var client = this._page.GetWebClient( );
                client.OpenReadCompleted += this.OnPlacesOpenReadCompleted;
                client.OpenReadAsync( address, userToken );
            }

            private void OnPlacesOpenReadCompleted( object sender, OpenReadCompletedEventArgs e ) {
                this._page.ReturnClient( sender );
                if( e.WasCancelled( ) ) {
                    typeof( ChooseLocationPage ).Log( "Search cancelled" );
                    return;
                }

                string key = (string)e.UserState;

                LocationEventArgs completedArgs;
                if( e.Error != null ) {
                    completedArgs = new LocationEventArgs( key, null, e.Error );
                }
                else {
                    try {
                        // Read places from stream.
                        using( e.Result )
                            completedArgs = new LocationEventArgs( key, Place.Load( Settings.DefaultUnits, e.Result, ForecastSource.GetCorrections( ) ) );
                    }
                    catch( FormatException ex ) {
                        ex.Report( Localized.ErrorSource.Locations );
                        completedArgs = new LocationEventArgs( key, null, ex );
                    }
                }

                if( key[0] == Airport ) {
                    if( completedArgs.Places != null && completedArgs.Places.Length > 0 )
                        this._locations[key] = completedArgs.Places;
                    this.BeginDownload( key );
                }
                else {
                    this.DownloadCompleted( this, completedArgs );
                }
            }
        }

        #endregion

        #region Location Discovery

        private const double Kilometer = 1000;
        private GeoCoordinateWatcher _watcher;

        private bool DiscoveringLocation {
            get { return this.DiscoverLocation.IsChecked == true; }
            set { this.DiscoverLocation.IsChecked = value; }
        }

        private void ToggleSearch( bool enable ) {
            this.Search.IsEnabled = enable;
            this.DiscoveringLocation = !enable;
        }

        private void ToggleWatcher( bool watch ) {
            if( this._watcher == null ) {
                this._watcher = new GeoCoordinateWatcher( GeoPositionAccuracy.Default ) { MovementThreshold = 10 * Kilometer };
                this._watcher.StatusChanged += this.OnGeoWatcherStatusChanged;
                this._watcher.PositionChanged += this.OnGeoWatcherPositionChanged;
            }

            if( watch )
                this._watcher.Start( );
            else
                this._watcher.Stop( );
        }

        private void OnDiscoverLocationClicked( object sender, RoutedEventArgs e ) {
            bool discover = this.DiscoveringLocation;
            this.DiscoverLocationChanged( discover );
        }

        private void DiscoverLocationChanged( bool discover ) {
            SearchInfoGroup group = this.GetSearchesForLocation( null );
            if( discover ) {
                this.SelectSearches( group );
                group.DiscoveredLocation( null );
                this.UpdateSearchProgress( group, Localized.Status.StartingLocationService, ProgressKind.Incomplete );
            }
            else {
                typeof( ChooseLocationPage ).Log( "Cancelling discover location searches." );
                foreach( SearchInfo search in group )
                    this.CancelSearch( search );
            }

            this.ToggleSearch( !discover );
            this.ToggleWatcher( discover );
        }

        private void OnGeoWatcherStatusChanged( object sender, GeoPositionStatusChangedEventArgs e ) {
            Localized.Status progressMessage;
            Localized.Popup? failureMessage;
            switch( e.Status ) {
                case GeoPositionStatus.Initializing:
                    progressMessage = Localized.Status.LocationServiceInitializing;
                    failureMessage = null;
                    break;

                case GeoPositionStatus.Ready:
                    progressMessage = Localized.Status.LocationServiceReady;
                    failureMessage = null;
                    break;

                default:
                case GeoPositionStatus.Disabled:
                    progressMessage = Localized.Status.LocationServiceDisabled;
                    failureMessage = Localized.Popup.LocationServiceDisabled;
                    break;

                case GeoPositionStatus.NoData:
                    progressMessage = Localized.Status.LocationServiceNoData;
                    failureMessage = Localized.Popup.LocationServiceNoData;
                    break;
            }

            bool failed = failureMessage.HasValue;
            SearchInfoGroup group = this.GetSearchesForLocation( null );
            this.UpdateSearchProgress( group, progressMessage, failed ? ProgressKind.CompletedUnsuccessfully : ProgressKind.Incomplete );
            if( failed ) {
                this.ToggleWatcher( watch: false );
                this.ToggleSearch( enable: true );
                failureMessage.Value.Show( );
            }
        }

        private void OnGeoWatcherPositionChanged( object sender, GeoPositionChangedEventArgs<GeoCoordinate> e ) {
            this.ToggleWatcher( watch: false );

            SearchInfoGroup group = this.GetSearchesForLocation( null );
            var position = e.Position.Location;
            if( position.IsUnknown ) {
                this.ToggleSearch( enable: true );
                this.UpdateSearchProgress( group, Localized.Status.LocationUnknown, ProgressKind.CompletedUnsuccessfully );
                Localized.Popup.LocationUnknown.Show( );
            }
            else {
                var location = new Coordinate( position.Latitude, position.Longitude );
                this.UpdateSearchProgress( group, Localized.Status.LocationFound, ProgressKind.Important, location.ToString( display: true ) );

                SearchInfo succeeded = group.Succeeded;
                if( succeeded != null )
                    this.OnForecastDownloadComplete( succeeded, location );
                else {
                    group.DiscoveredLocation( location );
                    this.TrySubscribe( group );
                }
            }
        }

        #endregion

        #region Forecast

        private Storyboard _storyboardPlaceholder;
        private Storyboard StoryboardPlaceholder {
            get {
                return this._storyboardPlaceholder
                   ?? (this._storyboardPlaceholder = new Storyboard( ));
            }
        }


        private void TrySubscribe( SearchInfoGroup group ) {
            foreach( SearchInfo search in group )
                this.TrySubscribe( search );
        }

        private bool TrySubscribe( SearchInfo search ) {
            if( search == null || !search.IsActive || search.IsFinished )
                return false;

            int settingsIndex = Settings.IndexOfLocation( search.Location );
            if( settingsIndex < 0 )
                this.Subscribe( search );
            else
                this.AlreadySubscribed( search, settingsIndex );
            return true;
        }

        private void AlreadySubscribed( SearchInfo search, int settingsIndex ) {
            search.AlreadySubscribed( );

            var locationSettings = Settings.GetLocationSettings( settingsIndex );
            Localized.FormatArgument locationName = search.Matches( locationSettings.Location, null ) ? locationSettings.Search : search.Text;
            this.UpdateSearchProgress(
                search,
                Localized.Status.AlreadySubscribed,
                ProgressKind.Complete,
                locationName.AddLogArgument( settingsIndex ) );
        }

        private void Subscribe( SearchInfo search ) {
            this.UpdateSearchProgress( search, Localized.Status.ContactingServer, ProgressKind.Incomplete );
            search.ContactingServer( );

            Coordinate location = search.FallbackLocation ?? search.Location;
            Place place = search.Place.GetValueOrDefault( );
            if( place.Location != location )
                place = new Place( location, search.Text );

            search.Source.GetSourceUrl( place, search, this.TryBeginForecastDownload );
        }

        private void TryBeginForecastDownload( SearchInfo search, string forecastUri ) {
            Coordinate location = search.Location;
            var locationSettings = new LocationSettings( location, search.Text, search.SourceId, forecastUri );
            bool began = this.BeginForecastDownload( locationSettings, forecastUri, DownloadReason.Subscription );
            if( !began )
                this.OnForecastDownloadComplete( search, location, null, null );
        }


        protected override void OnForecastDownloadComplete( ForecastEventArgs e ) {
            SearchInfoGroup group = this.GetSearchesForLocation( e.Location );
            SearchInfo search = SearchInfoGroup.Find( group, this.ForecastSourceId.GetValueOrDefault( ) );
            this.OnForecastDownloadComplete( search, e.Location, e.Forecast, this.ForecastUri );
        }

        private void OnForecastDownloadComplete( SearchInfo search, Coordinate location ) {
            this.OnForecastDownloadComplete( search, location, search.Forecast, search.DownloadUri );
        }

        private void OnForecastDownloadComplete( SearchInfo search, Coordinate location, Forecast forecast, string forecastUri ) {
            // If download failed and we still have a fallback location, retry.
            bool switchedToFallback = search.State != SearchState.Fallback;
            if( forecast == null && search.MoveNextFallbackLocation( ) ) {
                if( switchedToFallback )
                    this.UpdateSearchProgress( search, Localized.Status.LocationsNearby, ProgressKind.Important, search.Location.GetGeographicName( " " ) );

                this.Subscribe( search );
            }
            // Otherwise, update search with download result.
            else {
                Place place = search.ForecastDownloadCompleted( forecast, forecastUri );
                if( this.DiscoveringLocation && forecast != null )
                    this.Search.Text = place.CityName;

                // If forecast download succeeded for a new subscription, report success.
                int settingsIndex = forecast == null ? 0 : Settings.IndexOfLocation( forecast.Location );
                if( settingsIndex < 0 )
                    this.UpdateSearchProgress( search, Localized.Status.ForecastFound, ProgressKind.CompletedSuccessfully, new object[] { place.Name, search.Source } );
                // Otherwise, report failure.
                else if( search.HasSucceeded )
                    this.AlreadySubscribed( search, settingsIndex );
                else
                    this.UpdateSearchProgress( search, Localized.Status.LocationsNearbyUnavailable, ProgressKind.CompletedUnsuccessfully );

                this.ToggleSearch( enable: true );
            }
        }

        #endregion

        #region Searches

        private readonly List<SearchInfoGroup> _searches = new List<SearchInfoGroup>( );


        private bool HasLoaded { get { return this._searches.Count > 0; } }

        private SearchInfoGroup CurrentSearch { get { return this._searches[0]; } }


        private bool IsExistingSubscription( Place place, bool find ) {
            SearchInfoGroup current;
            if( find )
                current = this._searches.ElementAtOrDefault( this.FindSearchesForLocation( place.Location ) );
            else if( this._searches.Count == 1 )
                current = this.CurrentSearch;
            else
                current = null;

            return current != null
                && current.State == SearchState.Existing
                && current.Matches( place.Location );
        }

        private void EnsureSearchButtons( SearchInfoGroup group ) {
            if( group.Panel.Children.Count > 0 )
                return;

            foreach( SearchInfo search in group ) {
                var template = (DataTemplate)this.StatusPanel.Resources["SearchButtonTemplate"];
                search.Button = (Button)template.LoadContent( );
                search.Button.Tag = search;
                search.Button.DataContext = search.Status;
                search.Button.Click += this.OnSearchButtonClicked;
                group.Panel.Children.Add( search.Button );
            }

            this.StatusPanel.Insertimate( group.Panel, 0 );
        }

        private void OnCurrentSearchChanged( SearchInfoGroup group = null ) {
            if( group == null )
                group = this.CurrentSearch;

            // Ensure current search group is first in the panel.
            if( group.Panel.Children.Count == 0 )
                this.EnsureSearchButtons( group );
            else if( this.StatusPanel.Children.IndexOf( group.Panel ) > 0 )
                this.StatusPanel.Movimate( group.Panel, 0 );
        }

        private int FindSearchesForLocation( Coordinate? location ) {
            for( int index = 0; index < this._searches.Count; ++index ) {
                SearchInfoGroup group = this._searches[index];
                if( group.Matches( location ) )
                    return index;
            }

            return -1;
        }

        private SearchInfoGroup GetSearchesForLocation( Coordinate? location ) {
            SearchInfoGroup group;

            // Check for existing search at same location.
            int searchIndex = this.FindSearchesForLocation( location );

            // If we have an existing search group, use it.
            if( (uint)searchIndex < this._searches.Count ) {
                group = this._searches[searchIndex];
            }
            // Otherwise, create new search group.
            else {
                group = SearchInfo.Create( null );
                this._searches.Insert( 0, group );
                this.OnCurrentSearchChanged( );
            }

            return group;
        }

        private void SelectSearchGroup( SearchInfoGroup group ) {
            if( group != this.CurrentSearch ) {
                this._searches.Remove( group );
                this._searches.Insert( 0, group );
            }
        }

        private void SelectSearches( SearchInfoGroup group ) {
            this.SelectSearchGroup( group );
            this.OnCurrentSearchChanged( );
        }

        private SearchInfoGroup SelectSearchesFromPlace( Place place, string searchText ) {
            // Find or create info for specified place and set as current search.
            SearchInfoGroup group = this.GetSearchesForLocation( place.Location );
            this.SelectSearches( group );

            foreach( SearchInfo search in group )
                search.PlaceSelected( place, searchText );
            return group;
        }

        private void SelectSearchPlace( SearchInfo search ) {
            this.SelectSearchGroup( search.Group );
            this.OnCurrentSearchChanged( search.Group );
            if( !search.Place.HasValue )
                return;

            Place searchPlace = search.Place.Value;
            Place? selectedPlace = (Place?)this.Search.SelectedItem;
            if( selectedPlace != searchPlace ) {
                this.Search.ItemsSource = this.GetAvailablePlaces( search.Text );
                this.Search.SelectedItem = searchPlace;
            }

            this.SearchSelectionChanged( searchPlace, search );
        }


        internal override void SetProgress( PhoneApplicationPage page, ProgressKind kind, Localized.Status status, Localized.FormatArgument args ) {
            int searchIndex = this.FindSearchesForLocation( this.ForecastLocation );
            SearchInfoGroup group = this._searches.ElementAtOrDefault( searchIndex );
            SearchInfo search = SearchInfoGroup.Find( group, this.ForecastSourceId.GetValueOrDefault( ) );
            this.UpdateSearchProgress( search, status, kind, args );
        }

        private void UpdateSearchProgress( SearchInfoGroup group, Localized.Status status, ProgressKind kind, Localized.FormatArgument args = default(Localized.FormatArgument) ) {
            foreach( SearchInfo search in group )
                this.UpdateSearchProgress( search, status, kind, args );
        }

        private void UpdateSearchProgress( SearchInfo search, Localized.Status status, ProgressKind kind, Localized.FormatArgument args = default(Localized.FormatArgument) ) {
            string text, logMessage;
            bool clear = !status.GetProgressText( args, out text, out logMessage );
            if( clear || kind.HasFlag( ProgressKind.Complete ) )
                Extensions.SetProgress( this, kind, status, args );
            else if( !clear )
                typeof( ChooseLocationPage ).Log( logMessage ?? text );

            if( !clear )
                this.UpdateSearchProgress( search ?? this.CurrentSearch.First( ), text, kind );
        }

        private void UpdateSearchProgress( SearchInfo search, string text, ProgressKind kind ) {
            if( search.State == SearchState.Fallback && (!kind.HasFlag( ProgressKind.Important ) || kind.HasFlag( ProgressKind.Error )) )
                return;

            string status;
            if( search.HasSucceeded ) {
#if DEBUG
                search.Status.Updates.Add( new string( '—', 10 ) );
#else
                search.Status.Updates.Clear( );
#endif
                Coordinate location = search.Forecast != null ? search.Forecast.Location : search.FallbackLocation ?? search.Location;
                status = text + " (" + location.GetGeographicName( " " ) + ")";
            }
            else if( !kind.HasFlag( ProgressKind.Complete ) )
                status = "• " + text;
            else
                status = text;

            search.Status.Updates.Add( status );
            this.EnsureSearchButtons( search.Group );
        }


        private void CancelSearch( SearchInfo search ) {
            if( search.CancelForecastDownload( ) ) {
                this.UpdateSearchProgress( search, Localized.Status.Cancelling, ProgressKind.Incomplete );
                if( !search.Group.IsActive )
                    this.CancelForecastDownload( );
            }
        }

        private void OnSearchButtonClicked( object sender, RoutedEventArgs e ) {
            Button searchButton = (Button)sender;
            SearchInfo search = (SearchInfo)searchButton.Tag;

            switch( search.Status.Action ) {
                case SearchStatus.UserAction.Cancel:
                    typeof( ChooseLocationPage ).Log( "Cancelling selected search." );
                    if( search.IsDiscoveredLocation && this.DiscoveringLocation )
                        this.DiscoverLocationChanged( this.DiscoveringLocation = false );
                    else
                        this.CancelSearch( search );
                    break;

                case SearchStatus.UserAction.Save:
                    typeof( ChooseLocationPage ).Log( "Saving selected search." );
#if DEBUG
                    Extensions.SetProgress( this, ProgressKind.CompletedSuccessfully, Localized.Status.DebugForecastSaved );
#else
                    this.SaveSearchForecast( search );
#endif
                    break;

                case SearchStatus.UserAction.Refresh:
                    typeof( ChooseLocationPage ).Log( "Retrying selected search." );
                    search.Status.Updates.Clear( );

                    if( search.IsDiscoveredLocation )
                        this.DiscoverLocationChanged( this.DiscoveringLocation = true );
                    else
                        this.SelectSearchPlace( search );
                    break;

                default:
                case SearchStatus.UserAction.Minus:
                    typeof( ChooseLocationPage ).Log( "Ignoring unexpected action: " + search.Status.Action );
                    break;
            }
        }


        private enum SearchState {
            None = 0,
            Selected,
            ContactingServer,
            Fallback,

            Unsubscribed,
            Subscribed,
            Existing
        }

        private sealed class SearchInfoGroup : IEnumerable<SearchInfo> {
            private readonly SearchInfo[] _searches;
            private StackPanel _panel;

            public SearchInfoGroup( params SearchInfo[] searches ) {
                this._searches = searches;
                foreach( SearchInfo search in searches )
                    search.Group = this;
            }


            public int Count {
                get { return this._searches.Length; }
            }

            public SearchState State {
                get { return this._searches.Max( s => s.State ); }
            }

            public bool IsActive {
                get { return this._searches.Any( s => s.IsActive && !s.IsFinished ); }
            }

            public Coordinate Location {
                get { return this._searches.Select( s => s.Location ).FirstOrDefault( ); }
            }

            public string Text {
                get { return this._searches.Select( s => s.Text ).FirstOrDefault( ); }
            }

            public Place? Place {
                get { return this._searches.Select( s => s.Place ).FirstOrDefault( p => p.HasValue ); }
            }

            public SearchInfo Succeeded {
                get { return this._searches.FirstOrDefault( s => s.HasSucceeded ); }
            }

            public StackPanel Panel {
                get { return this._panel ?? (this._panel = new StackPanel( )); }
            }


            public static SearchInfo Find( SearchInfoGroup group, ForecastSourceId sourceId ) {
                var searches = group != null ? group._searches : Enumerable.Empty<SearchInfo>( );
                return searches.FirstOrDefault( s => s.SourceId == sourceId );
            }

            public bool Matches( Coordinate? location ) {
                return this._searches.Any( s => s.Matches( location, null ) );
            }

            public void DiscoveredLocation( Coordinate? location ) {
                foreach( SearchInfo search in this._searches )
                    search.DiscoveredLocation( location );
            }

            public override string ToString( ) {
                return string.Format( "{0}: Count={1}, Location={2}", GetType( ).Name, this.Count, this.Location );
            }


            #region IEnumerable Members

            public IEnumerator<SearchInfo> GetEnumerator( ) {
                return this._searches.Cast<SearchInfo>( ).GetEnumerator( );
            }

            System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator( ) {
                return this.GetEnumerator( );
            }

            #endregion
        }

        private sealed class SearchInfo {
            public static readonly SearchInfo DebugPlaceholder = new SearchInfo( null, 0 ) { _state = SearchState.Fallback };

            private readonly ForecastSourceId _sourceId;
            public ForecastSourceId SourceId { get { return this._sourceId; } }
            public ForecastSource Source { get { return ForecastSource.FromId( this.SourceId ); } }

            private readonly SearchStatus _status;
            public SearchStatus Status { get { return this._status; } }

            private SearchState _state;
            public SearchState State {
                get { return this._state; }
                private set {
                    System.Diagnostics.Debug.Assert(
                        value >= this._state || (this.IsFinished && !this.HasSucceeded) || (this.IsDiscoveredLocation && value == SearchState.Selected),
                        "Search transitioning from later state " + this._state + " to earlier state " + value, this.ToString( ) );
                    this._state = value;
                }
            }

            public bool IsDiscoveredLocation { get; private set; }
            public Coordinate Location { get; private set; }
            public Place? Place { get; private set; }
            public string PreferredUrl { get; private set; }
            public int WaitingCount { get; private set; }

            public string Text {
                get { return this._status.PlaceName; }
                private set { this._status.PlaceName = value; }
            }

            private bool IsTextUnset {
                get {
                    return string.IsNullOrWhiteSpace( this.Text )
                        || object.ReferenceEquals( this.Text, AppResources.ChooseLocationPage_Search_fallback )
                        || object.ReferenceEquals( this.Text, AppResources.ChooseLocationPage_Search_cancelled );
                }
            }

            public string DownloadUri { get; private set; }
            public Forecast Forecast { get; private set; }

            private IEnumerator<Coordinate> _fallbackLocations;
            public Coordinate? FallbackLocation { get; private set; }

            public SearchInfoGroup Group { get; set; }
            public Button Button { get; set; }

            public bool IsActive { get { return this.State >= SearchState.Selected; } }
            public bool IsFinished { get { return this.State >= SearchState.Unsubscribed; } }
            public bool HasSucceeded { get { return this.Forecast != null || this.State == SearchState.Existing; } }
            public bool IsIncomplete { get { return !this.IsActive || (this.IsFinished && !this.HasSucceeded); } }
            public bool ShouldRetry { get { return !this.IsFinished && this.WaitingCount <= 6; } }


            private SearchInfo( LocationSettings existingSubscription, ForecastSourceId sourceId ) {
                this._sourceId = sourceId;
                this._status = new SearchStatus( this, this.Source.Name );

                if( existingSubscription == null ) {
                    this.Text = "";
                }
                else {
                    this.State = SearchState.Existing;
                    this.Location = existingSubscription.Location;
                    this.Text = existingSubscription.Search.Trim( );
                }

                this.UpdateSelectionProgress( );
            }

            private static SearchInfo FromSourceId( ForecastSourceId sourceId ) {
                return new SearchInfo( null, sourceId );
            }

            public static SearchInfoGroup Create( LocationSettings existingSubscription ) {
                if( existingSubscription != null )
                    return new SearchInfoGroup( new SearchInfo( existingSubscription, existingSubscription.SourceId ) );

                var searches = ForecastSource.Supported.Select( FromSourceId ).ToArray( );
                return new SearchInfoGroup( searches );
            }


            public bool Matches( Coordinate? location, ForecastSourceId? sourceId ) {
                if( sourceId.HasValue && sourceId != this.SourceId )
                    return false;

                bool isMatch;
                if( location.HasValue ) {
                    double latitudeDifference = Math.Abs( location.Value.Latitude - this.Location.Latitude );
                    double longitudeDifference = Math.Abs( location.Value.Longitude - this.Location.Longitude );
                    isMatch = latitudeDifference < 0.015
                          && longitudeDifference < 0.015;
                }
                else {
                    isMatch = this.IsDiscoveredLocation || this.State == SearchState.None;
                }

                return isMatch;
            }


            public void DiscoveredLocation( Coordinate? location ) {
                this.IsDiscoveredLocation = true;

                // If we have discovered a location, save the new value.
                if( location.HasValue ) {
                    if( this.IsIncomplete )
                        this.State = SearchState.Selected;
                    this.Location = location.Value;
                }
                // Otherwise, reset state for new location search.
                else {
                    this.State = SearchState.Selected;
                    this.Location = default( Coordinate );
                    this.PreferredUrl = null;
                    this.DownloadUri = null;
                    this.Forecast = null;
                    this.Place = null;

                    this.Text = AppResources.ChooseLocationPage_Search_fallback;
                    this.Status.Updates.Clear( );
                }

                this.UpdateSelectionProgress( );
            }

            public void ContactingServer( ) {
                if( this.State != SearchState.Fallback )
                    this.State = SearchState.ContactingServer;
                ++this.WaitingCount;

                this.UpdateSelectionProgress( );
            }

            public void AlreadySubscribed( ) {
                this.State = SearchState.Existing;

                this.UpdateSelectionProgress( );
            }

            public void PlaceSelected( Place place, string text ) {
                System.Diagnostics.Debug.Assert(
                    this.Location == default( Coordinate ) || this.Matches( place.Location, this.SourceId ),
                    "Moving search from " + this.Location + " to " + place.Location, this.ToString( ) );

                if( this.IsIncomplete )
                    this.State = SearchState.Selected;
                this.Text = text ?? "";
                this.Place = place;
                this.Location = place.Location;
                if( !place.Name.EndsWith( ", US" ) )
                    this.PreferredUrl = place.Link;

                if( this._fallbackLocations == null )
                    this.Status.FallbackProgress = null;

                this.UpdateSelectionProgress( );
            }

            public bool CancelForecastDownload( ) {
                if( this.HasSucceeded )
                    return false;

                this.State = SearchState.Unsubscribed;
                if( this.IsDiscoveredLocation && this.IsTextUnset )
                    this.Text = AppResources.ChooseLocationPage_Search_cancelled;

                this.UpdateSelectionProgress( );
                return true;
            }

            public bool MoveNextFallbackLocation( ) {
                if( this.IsFinished )
                    return false;

                if( _fallbackLocations == null ) {
                    var progressContainer = new Grid( );
                    this.Status.FallbackProgress = progressContainer;
                    this._fallbackLocations = EnumerateFallbackLocations( this.Location, progressContainer );
                }

                bool hasFallback = this._fallbackLocations.MoveNext( );
                if( hasFallback ) {
                    this.State = SearchState.Fallback;
                    this.FallbackLocation = this._fallbackLocations.Current;
#if DEBUG_FALLBACK_FAILURE
                    this.FallbackLocation = this.Location;
#endif
                }
                else {
                    this.State = SearchState.Unsubscribed;
                    this.FallbackLocation = null;
                    this._fallbackLocations = null;
                }

                return hasFallback;
            }

            public Place ForecastDownloadCompleted( Forecast forecast, string downloadUri ) {
                this.Forecast = forecast;
                this.DownloadUri = downloadUri;

                Place place;
                if( forecast == null ) {
                    this.CancelForecastDownload( );
                    place = default( Place );
                }
                else {
                    if( !this.IsFinished )
                        this.State = SearchState.Subscribed;

                    place = Data.Place.Create( Settings.DefaultUnits, forecast.Location, forecast.Description );
                    if( this.IsTextUnset )
                        this.Text = place.CityName;

                    var fallbackProgress = (FrameworkElement)this.Status.FallbackProgress;
                    if( fallbackProgress != null ) {
                        var indicator = (UIElement)fallbackProgress.Tag;
                        if( indicator != null )
                            indicator.Opacity = 1.0;

                        Hide( fallbackProgress, TimeSpan.FromSeconds( 0.5 ) );
                    }
                }

                this.UpdateSelectionProgress( );
                return place;
            }

            public override string ToString( ) {
                string format;
                if( !this.IsFinished )
                    format = "Searching for \"{0}\": {1}" + (this.State == SearchState.ContactingServer || this.State == SearchState.Fallback ? " for {2}" : null);
                else if( this.HasSucceeded )
                    format = "Found forecast for {0}: {3} [{1}]";
                else
                    format = "Search for {0} failed: {1}";

                format += " [{3}] {4}";
                string prefix = "[" + this.Source + "] ";
                string target = this.IsTextUnset ? this.Location.ToString( ) : this.Text;
                return prefix + string.Format( format, target, this.State, this.WaitingCount, this.Forecast, this.Place );
            }

            private void UpdateSelectionProgress( ) {
                this.Status.DiscoverMarkVsibility = this.IsDiscoveredLocation ? Visibility.Visible : Visibility.Collapsed;
                this.Status.IsNew = this.State != SearchState.Existing;
                this.Status.IsSearching = !this.IsFinished;

                SearchStatus.UserAction userAction;
                double searchProgress;

                switch( this.State ) {
                    default:
                    case SearchState.None:
                    case SearchState.Selected:
                    case SearchState.ContactingServer:
                        userAction = SearchStatus.UserAction.Cancel;
                        searchProgress = 0;
                        break;

                    case SearchState.Subscribed:
                        userAction = SearchStatus.UserAction.Save;
                        searchProgress = 100;
                        break;

                    case SearchState.Unsubscribed:
                        userAction = SearchStatus.UserAction.Refresh;
                        searchProgress = this.WaitingCount;
                        break;

                    case SearchState.Existing:
                        userAction = SearchStatus.UserAction.Minus;
                        searchProgress = 0;
                        break;
                }

                this.Status.Action = userAction;
                this.Status.SearchProgress = searchProgress;
            }

            private static void Show( UIElement element ) {
                if( element == null )
                    return;

                var opacityAnimation = element.Animate( "Opacity", EasingMode.EaseIn, 1.0 );
                if( element.Opacity == 1.0 )
                    opacityAnimation.From = 0.0;

                var storyboard = new Storyboard { Children = { opacityAnimation } };
                storyboard.Begin( );
            }

            private static void Hide( UIElement element, TimeSpan? beginTime = null ) {
                if( element == null )
                    return;

                var opacityAnimation = element.Animate( "Opacity", EasingMode.EaseOut, 0.0, beginTime );
                var storyboard = new Storyboard { Children = { opacityAnimation } };
                storyboard.Begin( );
            }

            private static IEnumerator<Coordinate> EnumerateFallbackLocations( Coordinate location, Grid progressContainer ) {
#if DEBUG_FALLBACK_FAILURE
                const int MaximumLevel = 1;
#else
                const int MaximumLevel = 3;
#endif
                const int GridCount = 2 * MaximumLevel + 1;
                const int CellSize = 12;

                // Initialize container with appropriate number rows and columns for progress indicators.
                progressContainer.Width = progressContainer.Height = CellSize * GridCount;
                var gridSize = new GridLength( CellSize );
                for( int i = 0; i < GridCount; ++i ) {
                    progressContainer.RowDefinitions.Add( new RowDefinition { Height = gridSize } );
                    progressContainer.ColumnDefinitions.Add( new ColumnDefinition { Width = gridSize } );
                }

                const double IndicatorOpacity = 0.5;
                Thickness activeMargin = new Thickness( 2 );
                Thickness inactiveMargin = new Thickness( 3 );
                Thickness unvisitedMargin = new Thickness( 4 );
                var indicatorBrush = new SolidColorBrush( Colors.White );

                // Add central indicator for initial location.
                var centerIndicator = new Ellipse { Fill = indicatorBrush, Margin = inactiveMargin, Opacity = 1.5 * IndicatorOpacity };
                Grid.SetRow( centerIndicator, MaximumLevel );
                Grid.SetColumn( centerIndicator, MaximumLevel );
                progressContainer.Children.Add( centerIndicator );

                // Build rings of coordinates/progress indicators, outwards from the current location.
                var fallbacks = new List<Pair<Coordinate, Ellipse>>( );
                for( int level = 1; level <= MaximumLevel; ++level ) {
                    var increasing = Enumerable.Range( 1 - level, 2 * level );
                    var decreasing = increasing.Reverse( ).Select( r => r - 1 );
                    var offsets =
                        increasing.Select( r => Pair.Create( r, -level ) ).Concat(
                        increasing.Select( r => Pair.Create( level, r ) ) ).Concat(
                        decreasing.Select( r => Pair.Create( r, level ) ) ).Concat(
                        decreasing.Select( r => Pair.Create( -level, r ) ) );

                    foreach( var offset in offsets ) {
                        int xOffset = offset.Item1;
                        int yOffset = offset.Item2;

                        Coordinate fallback = new Coordinate(
                            location.Latitude + xOffset / 100.0,
                            location.Longitude + yOffset / 100.0 );

                        var indicator = new Ellipse { Fill = indicatorBrush, Margin = unvisitedMargin, Opacity = IndicatorOpacity };
                        progressContainer.Children.Add( indicator );
                        Grid.SetRow( indicator, xOffset + MaximumLevel );
                        Grid.SetColumn( indicator, yOffset + MaximumLevel );

                        fallbacks.Add( Pair.Create( fallback, indicator ) );
                    }
                }

                Show( progressContainer );

                // Enumerate each fallback.
                foreach( var fallback in fallbacks ) {
                    var indicator = fallback.Item2;
                    progressContainer.Tag = indicator;

                    indicator.Margin = activeMargin;
                    yield return fallback.Item1;
                    indicator.Margin = inactiveMargin;
                }
            }
        }

        #endregion
    }

}
